Contents
  1. 1. 背景
  2. 2. 遇到的问题
  3. 3. 问题原因
  4. 4. CAS单点登出原理和解决办法
  5. 5. 以下从代码角度进行分析
  • But
  • 背景


    系统内有多个应用,例如:admin、callcenter、frontweb、phone等,每个应用有多个运行的实例,例如:admin1、admin2、frontweb1、frontweb2、frontweb3等。同一个应用的实例可能分布在不同的实体机器上,也可能在相同的机器上,每一个实例就是一个tomcat。但是,所有访问必须先通过nginx,由它决定最终要访问哪个tomcat。

    现在,要增加单点登录和单点退出的功能,即在一个应用上登录后,在其他应用不需要重复登录了。

    遇到的问题


    按照网上的大多数流程配置完成后,在本地调试可以正常运行,也看不出什么问题,因为本地没有多个应用的实例,并且本地也没有配置nginx。

    但是在真实环境下,有多个admin和多个callcenter应用实例,单点登录没问题,但是当用户在admin退出后,之前登录的callcenter依旧可以正常使用,出问题了。

    问题原因


    经查实,原因出在logout时,由于nginx的存在,cas服务器发送的单点退出请求,没有经过nginx的转发到达用户登录的callcenter服务器上,而nginx也不确定这个请求应该转发到哪一台服务器上。所以退出失败。

    CAS单点登出原理和解决办法


    用户在登录admin时,会在CAS服务器使用该域名注册,以表示该用户在admin已经登录。同理,同一用户同一浏览器在登录callcenter时,也会在CAS服务器注册。当用户在其中一点退出后,CAS服务器会向在它上面注册过的所有应用发送退出请求。

    访问应用时,使用域名访问,通过nginx,但是注册
    时使用应用所在的ip和端口号。退出请求也要使用ip和端口号,不通过nginx。

    以下从代码角度进行分析


    Login flow(I mean the”login-webflow.xml”) does not contain all the login process. It onlyinclude the step where the browser is redirected to app with ST. BUT the STcheck URL can’t be found in thelogin-webflow.xml. So refer to “cas-servlet.xml” which involved allthe beans CAS used.

    在原始ServiceProperties中service参数是通过xml文件配置的。如果用以域名为开头的URL作为service参数。并且系统中每个应用有两个实例(每个实例可以看做是一个Tomcat),且这些应用是被放在nginx后面,每次访问都会经由nginx判断,从而最终选择那台服务器作为真正使用的服务器。理想的逻辑结构如下图。

    这样的架构对于单点登录没有影响,但是想要实现单点退出,却是要费一番脑经的。按照已有的架构,当用户在多个应用登陆成功时,每个应用都在CAS服务器进行注册。此时用户想要退出,需要在一个应用点击退出按钮,则该应用的所在的实例的session被销毁,并且将浏览器重定向到CAS服务器,通知CAS进行退出操作,待CAS完成退出操作后,就会向已经在CAS服务器注册过的每个应用发送退出请求,每个应用拦截该请求,完成退出登陆操作。
    等等,让我们在回到“CAS服务器向注册过的每个应用发送退出请求”这一步,针对现有架构,每一次访问都经过nginx,所以,CAS服务器在向每一个应用发送退出请求时,也是使用域名实现的,但是当nginx遇到以域名为开头的退出请求时,他不能准确指出,这个请求该发给哪个实例,不该发给哪个实例。因为,根据nginx的判断配置来看,是根据随请求携带的session来判断请求的转发目标的,而登录的时候,用户是通过浏览器访问应用的,但此时,访问该应用的是CAS服务器,所以,nginx不能准确判断该请求的转发目标,所以,单点退出失败。

    究其原因,是由登录流程引起的。从网上可以找到CAS单点登录的协作图,如下:
    在实际操作中,可能是因为代码的版本问题(具体情况不明确),针对上图做了一些修改,如下。
    logout时,CAS发出的请求的路径是在登录流程中设置的。研究代码,debug跟踪后,可以了解到改地址就是登录流程中的service参数。其设置是在第一次访问应用,并且重定向到CAS login界面时确定的|其设置实在用户输入用户名密码登陆成功后产生的|是在用户验证ST时,通过request产生的。
    针对以上描述情况,给ServiceProperties的service参数直接配置以域名开头的URL,不能解决单点退出。试想,如果不适用nginx,是不是就可以解决问题,把service URL中的域名改为真实的ip和端口号,以解决单点退出的问题。但是考虑到上图步骤3处,service地址需要重定向,局域网地址浏览器不能正常访问,所以获取tomcat的真实ip和端口号当做service参数的一部分。于是有了下图结构。
    这样设置时可以正常工作的,因为在步骤二时记录的service地址是ip和端口号,所以在logout时,CAS发出的请求不会通过nginx,直接根据ip和端口号进行访问。但是由于步骤3重定向地址是ip地址,所以登录成功后,用户浏览器地址栏也是ip和端口,美观不说还影响安全。此方案不可行。

    But

    试想是否可以让步骤3和logout时CAS请求的地址不同,比如,步骤三的地址使用域名,而logout时CAS请求的地址ip和端口号,而且对于CAS来说,其它应用在局域网内,这样就解决了问题。于是在上图的情况下稍微做些修改。
    根据协作图可知,步骤二的重定向地址由casEntryPoint也就是默认配置中的CasAuthenticationEntryPoint类进行计算得出,要想让CAS同时了解域名和ip,就必需在重定向地址中增加参数。为此继承CasAuthenticationEntryPoint而得CasTargetUrlAuthenticationEntryPoint

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    package com.xxxx.webapp.common.security.cas;

    import java.io.UnsupportedEncodingException;
    import java.net.URLEncoder;
    import org.springframework.security.cas.web.CasAuthenticationEntryPoint;

    import com.xxxxx.framework.common.log.Log;

    public class CasTargetUrlAuthenticationEntryPoint extends
    CasAuthenticationEntryPoint {
    private final static Log LOG = Log.getLog(CasTargetUrlAuthenticationEntryPoint.class);

    // targetUrl 代表用户访问的域名
    private String targetUrl;

    @Override
    protected String createRedirectUrl(String serviceUrl) {

    StringBuffer sb = new StringBuffer();
    sb.append(targetUrl).append("/").append(((CasServiceProperties)getServiceProperties()).getFilterProcessesUrl());

    String encodedOtherParameterValue = null;
    try {
    encodedOtherParameterValue = URLEncoder.encode(sb.toString(), "UTF-8");
    } catch (UnsupportedEncodingException e) {
    LOG.error("Cas login redirect url encode failed", e);
    }

    String redirectUrl = super.createRedirectUrl(serviceUrl);
    StringBuffer redirectUrlsb = new StringBuffer();
    redirectUrlsb.append(redirectUrl).append("&").append("targetUrl").append("=").append(encodedOtherParameterValue);

    redirectUrl = redirectUrlsb.toString();

    return redirectUrl;
    }

    public String getTargetUrl() {
    return targetUrl;
    }

    public void setTargetUrl(String targetUrl) {
    this.targetUrl = targetUrl;
    }

    }

    为了能让ip和端口号自动获取,所以ServiceProperties中的service参数不能用配置文件配置,所以继承ServiceProperties而得 CasServiceProperties

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    package com.xxxxx.webapp.common.security.cas;

    import java.lang.management.ManagementFactory;
    import java.net.InetAddress;
    import java.net.NetworkInterface;
    import java.net.SocketException;
    import java.net.UnknownHostException;
    import java.util.Enumeration;
    import java.util.Iterator;
    import java.util.Set;

    import javax.management.AttributeNotFoundException;
    import javax.management.InstanceNotFoundException;
    import javax.management.MBeanException;
    import javax.management.MBeanServer;
    import javax.management.MalformedObjectNameException;
    import javax.management.ObjectName;
    import javax.management.Query;
    import javax.management.ReflectionException;

    import org.apache.commons.lang.StringUtils;
    import org.springframework.security.cas.ServiceProperties;

    import com.xxxxx.framework.common.log.Log;

    public class CasServiceProperties extends ServiceProperties {
    private final static Log LOG = Log.getLog(CasServiceProperties.class);

    private final static String DEFAULT_LOCAL_IP = "127.0.0.1";

    private final String filterProcessesUrl;

    public CasServiceProperties(String filterProcessesUrl) throws MalformedObjectNameException, NullPointerException,
    UnknownHostException, AttributeNotFoundException, InstanceNotFoundException,
    MBeanException, ReflectionException, SocketException {

    MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
    Set<ObjectName> objs = mbs.queryNames(new ObjectName("*:type=Connector,*"),
    Query.match(Query.attr("protocol"), Query.value("HTTP/1.1")));
    LOG.debug("Objs size : {}", objs.size());

    String port = null;
    for (Iterator<ObjectName> i = objs.iterator(); i.hasNext(); ) {
    ObjectName obj = i.next();
    port = obj.getKeyProperty("port");

    if (StringUtils.isNotBlank(port)) {
    break;
    }
    }

    Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
    String tomcatIp = DEFAULT_LOCAL_IP;
    while (networkInterfaces.hasMoreElements()) {
    NetworkInterface networkInterface = (NetworkInterface) networkInterfaces.nextElement();
    Enumeration<InetAddress> inetAddresses = networkInterface.getInetAddresses();

    while (inetAddresses.hasMoreElements()) {
    InetAddress inetAddress = (InetAddress) inetAddresses.nextElement();

    if (inetAddress.isSiteLocalAddress()) {
    tomcatIp = inetAddress.getHostAddress();
    LOG.debug("Web server ip : {}.", tomcatIp);
    break;
    }
    }
    }

    this.filterProcessesUrl = filterProcessesUrl;
    StringBuffer sb = new StringBuffer();
    sb.append("http://").append(tomcatIp).append(":").append(port).append("/")
    .append(filterProcessesUrl);

    String finalServceUrl = sb.toString();
    LOG.debug("Service url : {}", finalServceUrl);

    setService(finalServceUrl);
    }

    public String getFilterProcessesUrl() {
    return filterProcessesUrl;
    }

    }

    最终的CAS Client的配置文件如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    <security:global-method-security />

    <security:http auto-config="true"
    entry-point-ref="casEntryPoint"
    access-denied-page="/403.jsp"
    use-expressions="true"
    access-decision-manager-ref="accessDecisionManager">

    <security:custom-filter ref="requestSingleLogoutFilter" before="LOGOUT_FILTER"/>
    <security:custom-filter ref="singleLogoutFilter" before="CAS_FILTER"/>
    <security:custom-filter ref="casProcessingFilter" position="CAS_FILTER" />

    <security:intercept-url pattern="/assets/**" access="permitAll" />
    <security:intercept-url pattern="/debug/**" access="permitAll" />
    <security:intercept-url pattern="/favicon.ico" access="permitAll" />
    <security:intercept-url pattern="/LogServlet" access="permitAll" />
    <security:intercept-url pattern="/security/**" access="permitAll" />
    <security:intercept-url pattern="/services/rs/**" access="permitAll" />
    <security:intercept-url pattern="/**" access="isAuthenticated()" />

    <security:logout logout-url="/security/logout.html" invalidate-session="false" success-handler-ref="logoutSuccessHandler"/>

    <security:session-management
    session-fixation-protection="none" />

    <!-- if enabled org.springframework.security.web.session.HttpSessionEventPublisher must be added into web.xml -->
    <security:session-management>
    <security:concurrency-control max-sessions="1"
    error-if-maximum-exceeded="true" />
    </security:session-management>

    </security:http>

    <!-- This filter redirects to the CAS Server to signal Single Logout should be performed -->
    <bean id="requestSingleLogoutFilter" class="org.springframework.security.web.authentication.logout.LogoutFilter">
    <constructor-arg value="${cas.server.url}/logout"/>
    <constructor-arg>
    <bean class="org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler">
    </bean>
    </constructor-arg>
    <property name="filterProcessesUrl" value="/j_spring_cas_security_logout"/>
    </bean>

    <!-- This filter handles a Single Logout Request from the CAS Server -->
    <bean id="singleLogoutFilter" class="org.jasig.cas.client.session.SingleSignOutFilter"/>

    <bean id="logoutSuccessHandler"
    class="com.xxxxx.webapp.common.security.employee.authn.DefaultLogoutSuccessHandler">
    <property name="defaultTargetUrl" value="/j_spring_cas_security_logout"></property>
    <property name="alwaysUseDefaultTargetUrl" value="true"></property>
    </bean>

    <bean id="casEntryPoint" class="com.xxxxx.webapp.common.security.cas.CasTargetUrlAuthenticationEntryPoint">
    <property name="loginUrl" value="${cas.server.url}/login"/>
    <property name="serviceProperties" ref="casServiceProperties"/>
    <property name="targetUrl" value="${platform.admin.server.url}"></property>
    </bean>

    <bean id="casServiceProperties" class="com.xxxxx.webapp.common.security.cas.CasServiceProperties">
    <constructor-arg value="${platform.cas.login.filter.processes.url}"/>
    <property name="sendRenew" value="false"/>
    <property name="authenticateAllArtifacts" value="true"/>
    </bean>

    <bean id="casProcessingFilter" class="org.springframework.security.cas.web.CasAuthenticationFilter">
    <property name="authenticationManager" ref="authenticationManager" />
    <property name="authenticationFailureHandler">
    <bean class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
    <property name="defaultFailureUrl" value="/casfailed.jsp" />
    </bean>
    </property>
    <property name="authenticationSuccessHandler">
    <bean class="com.xxxxx.webapp.common.security.employee.authn.DefaultAuthenticationSuccessHandler"/>
    </property>
    <property name="filterProcessesUrl" value="/${platform.cas.login.filter.processes.url}"></property>
    </bean>

    <security:authentication-manager alias="authenticationManager">
    <security:authentication-provider ref="casAuthenticationProvider" />
    </security:authentication-manager>

    <bean id="casAuthenticationProvider" class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
    <property name="authenticationUserDetailsService">
    <!-- <bean class="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
    <constructor-arg ref="userService" />
    </bean> -->
    <bean class="com.xxxxx.webapp.common.security.cas.CasUserDetailService" />
    </property>
    <property name="serviceProperties" ref="casServiceProperties"></property>
    <property name="ticketValidator">
    <bean class="org.jasig.cas.client.validation.Cas20ServiceTicketValidator">
    <constructor-arg index="0" value="${cas.server.url}"/>
    </bean>
    </property>
    <property name="key" value="security"/>
    </bean>

    <bean id="accessDecisionManager"
    class="org.springframework.security.access.vote.AffirmativeBased">

    <property name="decisionVoters">
    <list>
    <bean class="org.springframework.security.web.access.expression.WebExpressionVoter" />
    <bean class="com.xxxxxx.webapp.common.security.employee.authz.DefaultApplicationPermissionVoter" />
    </list>
    </property>
    </bean>

    CAS 服务端
    在验证ST的开始,需要从request中获取service对象,默认调用关系为:
    org.jasig.cas.web.ServiceValidateController.handleRequestInternal(HttpServletRequest,HttpServletResponse)——>org.jasig.cas.web.support.AbstractArgumentExtractor.extractService(HttpServletRequest)———>org.jasig.cas.web.support.CasArgumentExtractor.extractServiceInternal(HttpServletRequest)——>org.jasig.cas.authentication.principal.SimpleWebApplicationServiceImpl.createServiceFrom(HttpServletRequest)

    但是默认流程不能处理我们在CAS Client端加的targetUrl参数。最终的service对象就是SimpleWebApplicationServiceImpl,而它不能扩展,所以复制这个类,增加自己的变量。如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    import java.util.HashMap;
    import java.util.Map;

    import javax.servlet.http.HttpServletRequest;

    import org.jasig.cas.authentication.principal.AbstractWebApplicationService;
    import org.jasig.cas.authentication.principal.Response;
    import org.jasig.cas.authentication.principal.Response.ResponseType;
    import org.springframework.util.StringUtils;

    public class TargetUrlWebApplicationServiceImple extends AbstractWebApplicationService {

    private static final String CONST_PARAM_SERVICE = "service";

    private static final String CONST_PARAM_TARGET_SERVICE = "targetService";

    private static final String CONST_PARAM_TICKET = "ticket";

    private static final String CONST_PARAM_METHOD = "method";

    private static final String CONST_PARAM_ORIGIN_TARGET_URL = "targetUrl";

    private final ResponseType responseType;

    private String targetUrl;

    private static final long serialVersionUID = 8334068957483758042L;

    public TargetUrlWebApplicationServiceImple(final String id) {
    this(id, id, null, null, null);
    }

    private TargetUrlWebApplicationServiceImple(final String id,
    final String originalUrl, final String artifactId,
    final ResponseType responseType, String targetUrl) {
    super(id, originalUrl, artifactId);
    this.responseType = responseType;

    this.targetUrl = targetUrl;
    }

    public static TargetUrlWebApplicationServiceImple createServiceFrom(
    final HttpServletRequest request) {
    final String targetService = request
    .getParameter(CONST_PARAM_TARGET_SERVICE);
    String targetUrl = request
    .getParameter(CONST_PARAM_ORIGIN_TARGET_URL);
    final String method = request.getParameter(CONST_PARAM_METHOD);
    final String serviceToUse = StringUtils.hasText(targetService)
    ? targetService : request.getParameter(CONST_PARAM_SERVICE);

    if (!StringUtils.hasText(serviceToUse)) {
    return null;
    }

    final String id = cleanupUrl(serviceToUse);
    final String artifactId = request.getParameter(CONST_PARAM_TICKET);

    return new TargetUrlWebApplicationServiceImple(id, serviceToUse,
    artifactId, "POST".equals(method) ? ResponseType.POST
    : ResponseType.REDIRECT, targetUrl);
    }

    public Response getResponse(final String ticketId) {
    final Map<String, String> parameters = new HashMap<String, String>();

    if (StringUtils.hasText(ticketId)) {
    parameters.put(CONST_PARAM_TICKET, ticketId);
    }

    if (ResponseType.POST == this.responseType) {
    return Response.getPostResponse(targetUrl, parameters);
    }
    return Response.getRedirectResponse(targetUrl, parameters);
    }

    但是其调用类CasArgumentExtractor的方法也不能扩展,所以复制该类,更改为调用自己的实现类TargetUrlCasArgumentExtractor

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import javax.servlet.http.HttpServletRequest;

    import org.jasig.cas.authentication.principal.WebApplicationService;
    import org.jasig.cas.web.support.AbstractArgumentExtractor;

    public class TargetUrlCasArgumentExtractor extends AbstractArgumentExtractor {

    public WebApplicationService extractServiceInternal(final HttpServletRequest request) {
    return TargetUrlWebApplicationServiceImple.createServiceFrom(request);
    }

    }

    注意在uniqueIdGenerators.xml进行配置

    1
    2
    3
    4
    5
    6
    7
    8
    <util:map id="uniqueIdGeneratorsMap">
    <!-- <entry
    key="org.jasig.cas.authentication.principal.SimpleWebApplicationServiceImpl"
    value-ref="serviceTicketUniqueIdGenerator" /> -->
    <entry
    key="com.xxx.cas.webapp.authn.TargetUrlWebApplicationServiceImple"
    value-ref="serviceTicketUniqueIdGenerator" />
    </util:map>

    那么在用户调用重定向地址时,我们返回的是域名,而最终在CAS中注册,并且logout返回的都是ip和端口号。
    这样既能保证用户访问时,使用域名能够正常访问,还能保证一个用户在CAS注册时,使用的是ip,以至于当用户logout时,能够通过ip和端口号通知相应的服务器进行logout操作。

    Contents
    1. 1. 背景
    2. 2. 遇到的问题
    3. 3. 问题原因
    4. 4. CAS单点登出原理和解决办法
    5. 5. 以下从代码角度进行分析
  • But